AndResGuard 源码解析

AndResGuard 源码解析

AndResGuard 简介

AndResGuard 是一个开源工具,作用是帮助缩小 APK 大小。它的原理类似 Java Proguard,但是只针对资源。他会将原本冗长的资源路径变短,例如将 res/drawable/wechat 变为 r/d/a,同时支持压缩资源文件。

AndResGuard 不涉及编译过程,只需输入一个apk(无论签名与否,debug版,release版均可,在处理过程中会直接将原签名删除),可得到一个实现资源混淆后的apk(若在配置文件中输入签名信息,可自动重签名并对齐,得到可直接发布的apk)以及对应资源ID的mapping文件。

用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
apply plugin: 'AndResGuard'
buildscript {
repositories {
jcenter()
google()
}
dependencies {
classpath 'com.tencent.mm:AndResGuard-gradle-plugin:1.2.20'
}
}
andResGuard {
// mappingFile = file("./resource_mapping.txt")
mappingFile = null
use7zip = true
useSign = true
// 打开这个开关,会keep住所有资源的原始路径,只混淆资源的名字
keepRoot = false
// 设置这个值,会把arsc name列混淆成相同的名字,减少string常量池的大小
fixedResName = "arg"
// 打开这个开关会合并所有哈希值相同的资源,但请不要过度依赖这个功能去除去冗余资源
mergeDuplicatedRes = true
whiteList = [
// for your icon
"R.drawable.icon",
// for fabric
"R.string.com.crashlytics.*",
// for google-services
"R.string.google_app_id",
"R.string.gcm_defaultSenderId",
"R.string.default_web_client_id",
"R.string.ga_trackingId",
"R.string.firebase_database_url",
"R.string.google_api_key",
"R.string.google_crash_reporting_api_key"
]
compressFilePattern = [
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
]
sevenzip {
artifact = 'com.tencent.mm:SevenZip:1.2.20'
//path = "/usr/local/bin/7za"
}
/**
* 可选: 如果不设置则会默认覆盖assemble输出的apk
**/
// finalApkBackupPath = "${project.rootDir}/final.apk"
/**
* 可选: 指定v1签名时生成jar文件的摘要算法
* 默认值为“SHA-1”
**/
// digestalg = "SHA-256"
}

源码分析

主流程分析

下面开始分析 AndResGuard 的源码。克隆整个工程的源码,看根目录下的 AndResGuard 模块:

AndResGuard 是作为一个 gradle 插件提供给大家使用的。作为一个 gradle 插件,它的入口在 META-INF/gradle-plugins/AndResGuard.properties 里的 com.tencent.gradle.AndResGuardPlugin 类里。

AndResGuardPlugin 是使用 groovy 语言编写的。首先会执行 AndResGuardPlugin 的 apply 方法,此方法调用的 createTask 方法有两行代码:

1
2
3
4
def task = project.task(taskName, type: AndResGuardTask)
if (variantName != USE_APK_TASK_NAME) {
task.dependsOn "assemble${variantName}"
}

可知 android 打包任务完成后会执行 AndResGuardTask 任务。

AndResGuardTask 的 run 方法 -> Main.gradleRun(inputParam) -> resourceProguard() ,resourceProguard 方法如下:

1
2
3
4
5
6
7
8
9
10
11
File apkFile = new File(apkFilePath);
mRawApkSize = FileOperation.getFileSizes(apkFile);
try {
ApkDecoder decoder = new ApkDecoder(config, apkFile);
/* 默认使用V1签名 */
//对资源索引文件resources.arsc进行解码
decodeResource(outputDir, decoder, apkFile);
buildApk(decoder, apkFile, outputFile, signatureType, minSDKVersoin);
} catch (Exception e) {
...
}

decodeResource 方法,它调用了 ApkDecoder 的 decode() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void decode() throws AndrolibException, IOException, DirectoryException {
if (hasResources()) {
ensureFilePath();
// read the resources.arsc checking for STORED vs DEFLATE compression
// this will determine whether we compress on rebuild or not.
System.out.printf("decoding resources.arsc\n");
// 总共进行两次解析。第一次解析 resources.arsc 的时候把混淆前的资源名称保存到集合 mExistTypeNames 中,避免混淆后的名称与混淆前的名称出现相同的情况。第二次解析的时候才真正混淆。
RawARSCDecoder.decode(apkFile.getDirectory().getFileInput("resources.arsc"));
ResPackage[] pkgs = ARSCDecoder.decode(apkFile.getDirectory().getFileInput("resources.arsc"), this);
// 把没有记录在resources.arsc的资源文件也拷进dest目录
copyOtherResFiles();
// 重新生成 resources.arsc 文件
ARSCDecoder.write(apkFile.getDirectory().getFileInput("resources.arsc"), this, pkgs);
}

解析 resources.arsc

resources.arsc 文件结构图如下:

结构图

源码中总共进行了两次解析。

第一次解析

RawARSCDecoder.decode(apkFile.getDirectory().getFileInput(“resources.arsc”))

调用链如下: RawARSCDecoder#readTable() -> RawARSCDecoder#readTablePackage() -> RawARSCDecoder#readLibraryType() -> RawARSCDecoder#readTableTypeSpec() -> RawARSCDecoder#readConfig() ->RawARSCDecoder#readEntry() -> RawARSCDecoder#putTypeSpecNameStrings .

putTypeSpecNameStrings 方法会把 各种资源对应的原始资源名称保存到集合 mExistTypeNames 中。mExistTypeNames 的 key 为 资源类型,value 为该类型对应的资源名称列表。设置该集合的目的是避免混淆后的名称与混淆前的名称出现相同的情况。

第二次解析

ResPackage[] pkgs = ARSCDecoder.decode(apkFile.getDirectory().getFileInput(“resources.arsc”), this)

在 decoder.readTable() 方法里看一下解析过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private ResPackage[] readTable() throws IOException, AndrolibException {
nextChunkCheckType(Header.TYPE_TABLE);
int packageCount = mIn.readInt();
//读取 resources.arsc 的全局字符串池,保存到 mTableStrings
mTableStrings = StringBlock.read(mIn);
ResPackage[] packages = new ResPackage[packageCount];
nextChunk();
for (int i = 0; i < packageCount; i++) {
// 读取 resources.arsc 每个 packages 信息
packages[i] = readPackage();
}
...
return packages;
}

readPackage() -> readTableTypeSpec() -> readConfig() -> readEntry()

解析具体的资源项 readEntry 中调用了 readValue() 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
private void readValue(boolean flags, int specNamesId) throws IOException, AndrolibException {
...
//这里面有几个限制(比如只针对string类型),一对于string ,id, array我们是知道肯定不用改的,第二看要那个type是否对应有文件路径
if (mPkg.isCanResguard()
&& flags
&& type == TypedValue.TYPE_STRING
&& mShouldResguardForType
&& mShouldResguardTypeSet.contains(mType.getName())) {
if (mTableStringsResguard.get(data) == null) {
String raw = mTableStrings.get(data).toString();
if (StringUtil.isBlank(raw) || raw.equalsIgnoreCase("null")) return;
String proguard = mPkg.getSpecRepplace(mResId);
String newFilePath = raw.substring(0, secondSlash);
//同理这里不能用File.separator,因为resources.arsc里面就是用这个
String result = newFilePath + "/" + proguard;
int firstDot = raw.indexOf(".");
if (firstDot != -1) {
result += raw.substring(firstDot);
}
String compatibaleraw = new String(raw);
String compatibaleresult = new String(result);
//为了适配window要做一次转换
if (!File.separator.contains("/")) {
compatibaleresult = compatibaleresult.replace("/", File.separator);
compatibaleraw = compatibaleraw.replace("/", File.separator);
}
// 原始的资源文件
File resRawFile = new File(mApkDecoder.getOutTempDir().getAbsolutePath() + File.separator + compatibaleraw);
// 混淆名称后的文件
File resDestFile = new File(mApkDecoder.getOutDir().getAbsolutePath() + File.separator + compatibaleresult);
//合并重复的资源
MergeDuplicatedResInfo filterInfo = null;
boolean mergeDuplicatedRes = mApkDecoder.getConfig().mMergeDuplicatedRes;
if (mergeDuplicatedRes) {
filterInfo = mergeDuplicated(resRawFile, resDestFile, compatibaleraw, result);
if (filterInfo != null) {
resDestFile = new File(filterInfo.filePath);
result = filterInfo.fileName;
}
}
if (!resRawFile.exists()) {
} else {
if (filterInfo == null) {
//将混淆前的文件复制给混淆路径后的文件。例如将 res/layout/main.xml 复制给 r/l/a.xml文件
FileOperation.copyFileUsingStream(resRawFile, resDestFile);
}
//already copied
mApkDecoder.removeCopiedResFile(resRawFile.toPath());
// data 是entry的实际数据索引 result 是混淆后的全路径
mTableStringsResguard.put(data, result);
}
}
}
}

重新生成 resources.arsc

解析完成之后会重新生成 resources.arsc 文件,看一下 writeTable 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private void writeTable() throws IOException, AndrolibException {
System.out.printf("writing new resources.arsc \n");
mTableLenghtChange = 0;
writeNextChunkCheck(Header.TYPE_TABLE, 0);
int packageCount = mIn.readInt();
mOut.writeInt(packageCount);
// 重写全局字符串池,计算混淆后全局字符串池长度与混淆前的差值.后面 reWriteTable() 方法会用到 mTableLenghtChange
mTableLenghtChange += StringBlock.writeTableNameStringBlock(mIn, mOut, mTableStringsResguard);
writeNextChunk(0);
if (packageCount != mPkgs.length) {
throw new AndrolibException(String.format("writeTable package count is different before %d, now %d",
mPkgs.length,
packageCount
));
}
for (int i = 0; i < packageCount; i++) {
mCurPackageID = i;
//重写package
writePackage();
}
// 最后需要把整个的size重写回去
reWriteTable();
}

writePackage() ->

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private void writePackage() throws IOException, AndrolibException {
checkChunkType(Header.TYPE_PACKAGE);
int id = (byte) mIn.readInt();
mOut.writeInt(id);
mResId = id << 24;
//char_16的,一共256byte
mOut.writeBytes(mIn, 256);
/* typeNameStrings */
mOut.writeInt(mIn.readInt());
/* typeNameCount */
mOut.writeInt(mIn.readInt());
/* specNameStrings */
mOut.writeInt(mIn.readInt());
/* specNameCount */
mOut.writeInt(mIn.readInt());
StringBlock.writeAll(mIn, mOut);
if (mPkgs[mCurPackageID].isCanResguard()) {
// 重写资源名称
int specSizeChange = StringBlock.writeSpecNameStringBlock(mIn,
mOut,
mPkgs[mCurPackageID].getSpecNamesBlock(),
mCurSpecNameToPos
);
mPkgsLenghtChange[mCurPackageID] += specSizeChange;
mTableLenghtChange += specSizeChange;
} else {
StringBlock.writeAll(mIn, mOut);
}
writeNextChunk(0);
while (mHeader.type == Header.TYPE_LIBRARY) {
writeLibraryType();
}
while (mHeader.type == Header.TYPE_SPEC_TYPE) {
writeTableTypeSpec();
}
}

对于 writeSpecNameStringBlock 方法,该方法的第 3 个参数 Map> specNames ,specNames 的 key 代表资源项名称字符串(取值有 3 种情况,混淆前的名称、混淆后的名称、固定字符串 fixedResName = “arg”)。该方法行数较长,看一下关键代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
for (Iterator<String> it = specNames.keySet().iterator(); it.hasNext(); ) {
stringOffsets[i] = offset;
String name = it.next();
for (String specName : specNames.get(name)) {
// N res entry item point to one string constant
// 多个 entry 的名称指向同一个常量字符串
curSpecNameToPos.put(specName, i);
}
if (isUTF8) {
stringBytes[offset++] = (byte) name.length();
stringBytes[offset++] = (byte) name.length();
totalSize += 2;
byte[] tempByte = name.getBytes(Charset.forName("UTF-8"));
if (name.length() != tempByte.length) {
throw new AndrolibException(String.format(
"writeSpecNameStringBlock %s UTF-8 length is different name %d, tempByte %d\n",
name,
name.length(),
tempByte.length
));
}
// 重写资源项名称字符串池
System.arraycopy(tempByte, 0, stringBytes, offset, tempByte.length);
offset += name.length();
stringBytes[offset++] = NULL;
totalSize += name.length() + 1;
} else {
//省略...
}
i++;
}

重新签名打包

该部分工作在 buildApk(decoder, apkFile, outputFile, signatureType, minSDKVersoin) 方法里完成。重新打包完成后,需要重新签名,以 APK 签名方案 v2 签名为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void buildApkWithV2sign(HashMap<String, Integer> compressData, int minSDKVersion) throws Exception {
insureFileNameV2();
// 重新打包(未签名的安装包)
generalUnsignApk(compressData);
//如果配置文件里配置了 7z 压缩,则根据配置对资源进行压缩
if (use7zApk(compressData, mUnSignedApk, m7ZipApk)) {
// 下文使用了 apksigner 工具完成签名。如果使用的是 apksigner,只能在为 APK 文件签名之前执行 zipalign
alignApk(m7ZipApk, mAlignedApk);
} else {
alignApk(mUnSignedApk, mAlignedApk);
}
/*
* Caution: If you sign your app using APK Signature Scheme v2 and make further changes to the app,
* the app's signature is invalidated.
* For this reason, use tools such as zipalign before signing your app using APK Signature Scheme v2, not after.
**/
// 使用了 apksigner 工具完成签名
signApkV2(mAlignedApk, mSignedApk, minSDKVersion);
//输出结果,如果不设置则会默认覆盖assemble输出的apk
copyFinalApkV2();
}